iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 2

Day 2|我的 Service 呢?沒有 DI 的第一週

  • 分享至 

  • xImage
  •  

本系列討論基準
Angular 以 v20 為主(standalone components、Signals、inject() 為預設寫法)。
React 這邊指以使用React Router 為框架的專案為主,非 CRA 或純 Vite SPA。
版本迭代像是變了心的女友,文中若有出入,以官方版本文件為準。

先承認一件事

我寫 Angular 三年多,從來沒有認真想過HttpClient 是誰建的。大多數時候就是拿了就用。
這種理所當然,是框架幫你養出來的習慣——直到換去 React,那一行注入不見了,才發現自己從來不知道它背後在做什麼。

所以今天先把 Angular 那套講清楚,再來看 React 的答案。


一、Angular 的 DI:你要什麼,說一聲就好

假設沒有 DI 的世界:

class ProductService {
  private http = new HttpClient();  // 我自己 new 一個
}

三個問題:跟 HttpClient 的建構方式綁死了;每個用到的地方都會生一份新的;測試時想換成假的,得改原始碼。

Angular 的寫法是:

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  getProducts() {
    return this.http.get<Product[]>('/api/products');
  }
}

ProductService 只宣告「我需要一個 HttpClient」,至於那個實例是誰建的、生命週期如何,通通不管。

負責管這些的東西叫 Injector。它維護兩張表:哪個 token 該怎麼建,以及已經建好的實例放在哪。你呼叫 inject(HttpClient) 時,它先查快取,有就給你,沒有就照設定建一份再給你。

四個構件

構件 做什麼 範例
Token 依賴的識別碼 class 本身,或 InjectionToken
Provider 告訴 injector 這個 token 對應什麼 providers: [{ provide: X, useClass: Y }]
Injector 維護對應表、負責建立 框架內建,有層級
注入點 取用的語法 inject(X)

這四個構件出自同一張設計圖,彼此本來就是為了配合彼此而存在。這件事等一下會變得很關鍵。

它們換來三件事。

生命週期有人管。 providedIn: 'root' 的 service 全 app 只有一份,因為快取只存一份。

可以換掉。 測試時換一行,被測的程式完全不動:

TestBed.configureTestingModule({
  providers: [
    { provide: ProductService, useClass: MockProductService }
  ]
});

Injector 是有層級的。 它不只有一個,而是一棵樹,形狀跟元件樹平行。inject(X) 時先問自己有沒有 X 的 provider,沒有就往上一層問,一路問到 root,第一個找到的就用;到頂都沒有,就是 NullInjectorError

所以同一個 service,登記的位置不同,共用範圍就不同:

// 全 app 一份
@Injectable({ providedIn: 'root' })
export class CartService {}

// 每個 component 實例各自一份
@Component({
  selector: 'app-product-panel',
  providers: [PanelStateService],   // 👈 關鍵在這
})
export class ProductPanelComponent {
  private state = inject(PanelStateService);
}

畫面上三個 panel,就有三份互不干擾的 state——因為每個 component 實例有自己的 injector,各自快取當然獨立。

「共用」跟「獨立」是同一套機制的兩種設定。不用換寫法,只要換登記的位置,交給框架處理。


二、React 的答案:先講最常用到的

可能多數 React 專案是這樣:

// lib/api.ts
export const apiClient = axios.create({
  baseURL: `${import.meta.env.VITE_API_URL}/api/`,
  timeout: 240000,
});
import { apiClient } from '@/lib/api';

九成場景夠用,因為 ES module 本身就是單例——一個 module 在整個 module graph 裡只求值一次,所有 import 拿到的都是同一份。providedIn: 'root' 花力氣做的「全域唯一」,JavaScript 的模組系統就有提供了。

只是要記得:這是模組載入機制的副作用,不是設計當 DI 容器用的。它的範圍是「一個 process 內唯一」。

而且它不是 DI,是硬相依。元件寫死了要用哪個實作,要換掉只能改原始碼,或用 vi.mock 從建置層面動手腳。

DI 的核心價值從來不是「拿到東西」,而是「拿到的是什麼,由外面決定」。


三、一開始寫的時候,真的想把 DI 搬過來

先講清楚,免得你以為 React 藏了一套 DI 沒告訴你:它沒有,它只是剛好有幾個零件,拼起來很像。

ES Module 是為了程式碼組織與 tree-shaking,單例只是附加的;Context 是為了解決 prop drilling,不是為了做可替換的服務層;Hook 是為了封裝有狀態邏輯,不是為了當注入點。這幾個東西被造出來的目的,跟依賴注入通通無關。

Angular 那四個構件是一台組好的車,你今天要開,直接買了上車就走。React 這邊車庫裡沒車,但地上有一堆零件,你可以自己拼一台。

而我第一個月,因為離不開 Angular 給的安全感,就真的想試試。

拼起來是這樣(新手轉換框架中,如有更好做法請留言,一起進步)

以簡單的 ApiService 為範例。Angular 的流程是三段:

ApiService → Injector → Component

React 步驟變多了,中間多出來那段就是你要自己動手的部分:

ApiService → Provider 提供 → Context 保存 → useContext 取得 → Component 

第一步,服務本身。@Injectable()、Spring 的 @Service 是同一個東西,只是沒有裝飾器——因為沒有容器要讀它。

class ApiService {
  fetchData() {
    return '這是從 API 取得的真實資料!';
  }
}

第二步,開一個插座。 這行是宣告「未來這裡會放 ApiService」,插座此時是空的。它等同於 Angular 的 token:一個代表依賴的識別碼,本身不含實例。

const ApiServiceContext = createContext<ApiService | null>(null);

第三步,注入真正發生在這裡。

const apiService = new ApiService();

<ApiServiceContext.Provider value={apiService}>
  <Dashboard />
</ApiServiceContext.Provider>

value={apiService} 就是註冊,對應 Angular 的 providers: [ApiService]。差別在於 Angular 是宣告(我登記一下,你負責建),React 是指派(我建好了,拿去)。誰負責 new,這是兩邊最大的分水嶺。

第四步,取用。 React 沿著元件樹往上找最近的 Provider——跟 injector 往上找的心智模型一樣,只是一邊找 injector 樹、一邊找元件樹。

const apiService = useContext(ApiServiceContext);

也可以再包一層變客製化 Hook

通常包成 hook 把 Context 藏起來:

export function useApi() {
  const api = useContext(ApiServiceContext);
  if (!api) throw new Error('useApi 必須在 ApiProvider 底下使用');
  return api;
}
const api = useApi();   // 👈 這就是 inject(ApiService)

那個 throw 就是 React 版的 NullInjectorError,只是要自己寫。

建立實例的部分也會包進 Provider:

function ApiProvider({ children }: { children: ReactNode }) {
  const api = useMemo(() => new ApiService(), []);   // 👈 注意這裡
  return (
    <ApiServiceContext.Provider value={api}>
      {children}
    </ApiServiceContext.Provider>
  );
}

那個 useMemo 不是效能優化,是正確性。少了它,每次 ApiProvider 重新 render 都會 new 一個新的,底下所有 useApi() 拿到的實例就換人了——服務裡的狀態、快取、連線全部歸零。

這種錯在 Angular 不可能發生,因為實例是 Injector 建的、存在快取裡,跟 render 無關。到了 React,「這個東西活多久」變成你要自己處理。最後我把它們砍掉,改回 import,簡單多了。


四、所以為什麼 React 社群不做一套容器

不是做不出來,是划不來

前端通常只有 ApiClientAuthServiceConfigLogger 這幾種東西需要唯一實例,數量少、依賴關係淺。我後端寫 Spring Boot,那邊動輒幾百個 Bean 互相注入,沒有容器根本無法管理,@Autowired 是剛需——但前端不是那個規模。

所以 React 的預設立場是:先用 import,等真的需要可替換性再升級。 什麼叫真的需要?同一個介面有多種實作、多租戶要換設定、feature flag 要切行為、想在測試裡避開 module mock。都沒有的話,直接 export const apiClient 就是正確答案。

這不是偷懶,是誠實——不需要的抽象層是多餘成本,不是品質。

Angular 的立場相反:它預設你會需要,所以先幫你裝好。這也不是浪費,因為它面對的是大型、長壽、多人維護的專案,那種情境下可替換性遲早會用到,所以金融業大型系統常用 Angular 來寫,保持一致性。

兩邊都對,只是賭的東西不一樣。


今天的結論

React 做得到 DI,概念都沒少。真正的差別不在能不能,而在預設值:Angular 預設幫你開好,你得學它的規則,不學就拉倒;React 預設不開,你需要時自己拼,而且每個依賴都要重拼一次。

前者的代價是學習曲線,後者的代價是判斷力——你得知道什麼時候該拼、什麼時候不該。

而且我開始察覺一件事:React 把很多原本由框架承擔的責任,還給了 JavaScript 本身。模組單例、閉包、Reference等等——這些在 Angular 裡被包起來的東西,在 React 裡會直接決定你的程式對不對。那個 useMemo 就是一個例子:少寫它不會報錯,只會讓你的服務悄悄換人。寫 React 需要更懂 JS 底層,這件事往後幾天我會慢慢再多思考。

Angular 假設你會把架構寫壞,所以先幫你定好;React 假設你知道自己在幹嘛。 當你真的知道的時候,React 很爽。當你不知道的時候,Angular 就是會救你一命避免犯錯。


上一篇
Day 1|寫了三年多 Angular,然後被告知下個專案用 React...
下一篇
Day 3|從 @if/@for 到 JSX:畫面交還給 JavaScript
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言